08 - 评测与接回推理框架
专题的最后一篇,两件事:给模型打个分,然后把权重交给自制推理框架跑起来。
第二件事是整个专题的收口。训练和推理两条线在这里接上,说明这个模型不只是训练脚本里的一堆张量,而是一个真能被独立加载、独立跑的东西。
前置:06 或 07 篇产出的 checkpoint。
零、开始之前:为什么要评测
0.1 loss 低不代表模型好
03 篇一直盯着 loss,它确实是训练是否正常的核心指标。但 loss 有个根本局限:它衡量的是模型对训练分布的拟合程度,不是模型有多好用。
一个只在中文网页上 训的模型,在中文网页上 loss 会很低,但它可能完全不会做数学题。loss 不会告诉你这件事。
评测就是拿一些外部的、有标准答案的任务去问模型,看它答得怎么样。
0.2 评测要回答三个问题
| 问题 | 用什么测 |
|---|---|
| 训练有没有真的学到东西 | 困惑度,跟随机初始化和早期 checkpoint 比 |
| 有没有学到具体知识 | 选择题评测(MMLU、C-Eval) |
| 后训练有没有起作用 | 对比 base / SFT / DPO 三个模型的输出 |
0.3 先把预期放对:0.5B 能到什么水平
这一节决定了如何解读评测结果。
0.5B 的模型在知识类评测上,成绩大概率接近随机猜。 MMLU 和 C-Eval 都是四选一,随机基线 25%,0.5B 训 10B token 通常也就在 25% 到 30% 之间。
这不是训错了,是规模决定的。这类评测考的是世界知识,而知识的容量跟参数量强相关。0.5B 装不下那么多东西。
那还测什么。测这几样:
- 困惑度在下降,说明预训练有效
- HellaSwag 这类常识补全比随机好,说明学到了语言规律(它考的是「哪个续写更合理」,不是硬知识)
- base / SFT / DPO 的输出差异肉眼可见,说明后训练链路是通的
最实在的验收是最后那条:同一个问题问三个模型,base 在续写、SFT 在回答、DPO 答得更整齐。这个对比能证明整条链路是通的,比一个 27% 的 MMLU 分数有意义得多。
一、评测分两大类
1.1 判别式:选择题
给题干和几个选项,让模型挑一个。MMLU、C-Eval、HellaSwag 都是这类。
好处是判分完全客观,不需要另一个模型或人来评价。这一篇主要讲这类。
1.2 生成式:让模型自由回答
比如 GSM8K 数学题,模型要写出解题过程和答 案。判分要么用规则抽取最终答案比对,要么请一个更强的模型当裁判。
0.5B 做不了这类任务,这一篇不展开。
二、选择题怎么算分
2.1 不要让模型生成字母
最直觉的做法是把题目和选项拼好,让模型生成,看它输出的是不是 "B"。
这个做法对小模型完全不适用。 0.5B 的模型经常连一个合法的选项字母都吐不出来,它可能输出「答案是」「我认为」,或直接续写题干。
这样一来,「不会答」和「格式不对」就被混为一谈了,测出来的分数没有意义。
2.2 正确做法:比较四个选项的对数概率
把每个选项分别拼到题干后面,算模型给这个选项的对数概率,谁高选谁:
prompt = "中国的首都是哪里?\nA. 上海\nB. 北京\nC. 广州\nD. 深圳\n答案:"
分别算:
logP(" A" | prompt)
logP(" B" | prompt)
logP(" C" | prompt)
logP(" D" | prompt)
lm-evaluation-harness 等标准框架都采用这个方案。这样模型一定会给出一个选择,不存在格式问题。这是 lm-evaluation-harness 等标准评测框架的做法。
@torch.no_grad()
def option_logprob(model, tok, prompt, option, device, normalize=True):
p_ids = tok.encode(prompt).ids
o_ids = tok.encode(option).ids
ids = torch.tensor([p_ids + o_ids], device=device)
logits, _ = model(ids)
logprobs = F.log_softmax(logits[0].float(), dim=-1)
total = 0.0
for k, tid in enumerate(o_ids):
pos = len(p_ids) + k - 1 # 第 i 个 token 由第 i-1 个位置预测
total += logprobs[pos, tid].item()
return total / len(o_ids) if normalize else total
pos = len(p_ids) + k - 1 仍是那个错位关系:第 i 个 token 由第 i-1 个位置的输出预测。01 篇 0.3 节、06 篇 2.5 节、07 篇 3.1 节都是同一件事。
2.3 长度归一化的坑
如果选项不是 A/B/C/D 而是完整句子(HellaSwag 就是这样),会遇到一个问题。
对数概率是每个 token 的对数概率求和,而每个 token 的对数概率都是负数。所以选项越长,总和越小。不做处理的话,模型会系统性地偏向最短的选项。
解决办法是除以 token 数,得到平均每 token 的对数概率:
return total / len(o_ids) if normalize else total
要注意这不是唯一做法,标准评测框架里通常会同时报归一化和不归一化两个分数(acc 和 acc_norm)。跟别人的分数对比时要确认用的是同一种,否则数字没有可比性。
对 A/B/C/D 这种等长选项,归不归一化没差别。
2.4 few-shot 提示
很多评测报告写的是「5-shot」,意思是在题目前面先放 5 个带答案的示例:
def build_prompt(question, choices, few_shot=None):
parts = []
for q, ch, ans in (few_shot or []):
block = q + "\n" + "\n".join(f"{k}. {v}" for k, v in ch.items())
parts.append(block + f"\n答案:{ans}\n")
block = question + "\n" + "\n".join(f"{k}. {v}" for k, v in choices.items())
parts.append(block + "\n答案:")
return "\n".join(parts)
few-shot 的作用是告诉模型「这是个选择题,该输出选项字母」。对 base 模型帮助很 大,对 SFT 之后的模型帮助小一些,因为它已经知道该回答问题了。
比较不同模型的分数时,shot 数必须一致。 0-shot 和 5-shot 的分数差好几个点是常事。
三、能跑的评测集
2026-08-19 用 HuggingFace API 实测:
| 数据集 | 行数 | 许可 | 考什么 | 0.5B 的预期 |
|---|---|---|---|---|
cais/mmlu | 231,400 | MIT | 英文 57 学科知识,四选一 | 接近 25% 随机 |
ceval/ceval-exam | 13,948 | CC-BY-NC-SA-4.0 | 中文 52 学科,四选一 | 接近 25% 随机 |
Rowan/hellaswag | 59,950 | — | 常识续写,四选一 | 可能到 30% 以上 |
openai/gsm8k | 17,584 | MIT | 小学数学应用题,生成式 | 基本是 0 |
ceval/ceval-exam 是 CC-BY-NC-SA,非商用。
建议先跑 HellaSwag。 它考的是语言规律不是硬知识,0.5B 有机会明显超过随机基线,能真正验证预训练有效。MMLU 和 C-Eval 跑一下做记录就行,别指望好看。
四、困惑度
4.1 就是 loss 取指数
困惑度(perplexity,PPL)的定义:
直觉解释:模型在预测下一个 token 时,相当于在多少个候选之间犹豫。PPL 等于 10,表示模型的不确定性相当于在 10 个候选里均匀猜。
对照表:
| loss | PPL | 含义 |
|---|---|---|
| 10.37 | 32,000 | 随机初始化,正好等于词表大小 |
| 6.00 | 403 | |
| 4.00 | 55 | |
| 3.00 | 20 | 小模型常见落点 |
| 2.00 | 7 |
最上面那行值得注意 :随机初始化时 PPL 正好等于词表大小 32000。因为模型在 32000 个 token 里均匀猜,不确定性就是 32000。
这跟 02 篇 8.3 节那个 ln(32000) = 10.3735 是同一件事的两种说法,指数和对数互为逆运算。
4.2 PPL 只能跟自己比
PPL 受两个东西影响很大:测试语料和 tokenizer。
换个语料,PPL 就变。换个 tokenizer,切分粒度变了,PPL 更是完全没有可比性——词表小的模型每个 token 携带信息少,PPL 天然更低,但这不代表它更好。
所以看到「我的模型 PPL 比 GPT-2 低」这种说法,基本没有意义,除非两者用同一个 tokenizer 在同一份语料上测。
PPL 的正确用法是纵向对比自己:训练早期 vs 后期、不同超参、不同 checkpoint。这时候语料和 tokenizer 都固定,比较才成立。
五、接回自制推理框架
5.1 这一步为什么是收口
到这里为止,模型一直活在训练脚本里。要证明它是个独立的东西,就得让另一套代码把它加载起来并正常工作。
这也是最容易出问题的一步。权重加载成功、不报任何错、但输出是胡言乱语,是这一步的典型状态。
5.2 三个必须对齐的约定
模型不只是权重,还有一堆隐含约定。训练端和推理端有任何一处对不上,输出就废。
一是 RoPE 的配对方式。 02 篇 3.7 节专门讲过:rotate_half(前后半配对,HuggingFace 约定)和交错配对(相邻两维,原论文约定)都能训,但互不兼容。我们用的是 rotate_half,推理端必须一样。
二是 tokenizer。 必须是 01 篇训出来的同一个 tokenizer.json。换一个词表,token id 的含义全变了。
三是对话模板。 06 篇的 ChatML 格式,推理时拼 prompt 要用同一套,<|im_start|> 和 <|im_end|> 也要被识别成单个 token。
这三条里,RoPE 那条最隐蔽,因为另外两条错了通常会立刻输出乱码,而 RoPE 错了模型还能吐出通顺的词,只是内容毫无逻辑,容易被误判成「模型就是这么菜」。
5.3 逐层比对:怎么定位到底哪错了
输出不对但不知道哪一步出的问题,靠猜很慢。正确做法是逐层比对:同一份输入,同一份权重,看两边每一层的输出从哪里开始分叉。
00-pretrain-0.5b/compare_impl.py 用 forward hook 抓训练端每一层的输出:
def capture_activations(model, input_ids):
acts = {}
handles = []
def mk(name):
def hook(_module, _inp, out):
acts[name] = (out[0] if isinstance(out, tuple) else out).detach().float()
return hook
handles.append(model.embed.register_forward_hook(mk("embed")))
for i, blk in enumerate(model.blocks):
handles.append(blk.attn.register_forward_hook(mk(f"block{i}.attn")))
handles.append(blk.mlp.register_forward_hook(mk(f"block{i}.mlp")))
handles.append(blk.register_forward_hook(mk(f"block{i}.out")))
...
然后逐个比最大绝对差,标出第一处超阈值的层。第一处分叉的位置直接指向病因:
| 第一处对不上 | 大概率是什么 |
|---|---|
embed 就偏 | tokenizer 不是同一个,或权重共享没处理 |
block*.attn 先偏 | RoPE 约定,或 GQA 的 repeat_kv |
block*.mlp 先偏 | SwiGLU 的 gate 和 up 接反了 |
只有 logits 偏 | lm_head 的权重共享没接上 |
用法上有个细节:脚本默认先自己跟自己比,结果应该全是 0。这一步是在验证比对逻辑本身没写错——如果自比对都不是 0,那说明抓 activation 的代码有问题,后面比出来的差异都不可信。
python compare_impl.py --ckpt out_sft/sft_epoch2.pt
确认自比对全 0 之后,把 other 换成自己推理框架的输出再比。
5.4 数值容差取多少
两边实现不可能逐位相同,因为算子的执行顺序、是否用了融合 kernel 都会造成浮点误差。
经验阈值:bf16 下逐层最大绝对差在 1e-2 量级是正常的,1e-3 以内很好。如果某一层突然跳到 1e-1 甚至 1 以上,那就不是数值误差,是实现不一致。
看相对差比绝对差更可靠,因为深层的激活值本身数量级就大。compare_impl.py 两个都打。
5.5 KV cache 的正确性怎么验
推理框架会引入训练时不存在的东西:KV cache。它是加速手段,不该改变输出。
验证方法很简单:同一段 prompt,一次性前向(不用 cache)和逐 token 生成(用 cache),两者在相同位置的 logits 应该一致。
不一致的常见原因是 RoPE 的位置索引错了——用 cache 时每步只送一 个新 token,但它的位置应该是 cache_len,而不是 0。这个 bug 的表现很典型:生成的前几个 token 正常,越往后越乱。
六、采样策略
模型输出的是 32000 个 token 的概率分布,怎么从里面选一个,有几种做法。
| 策略 | 做法 | 什么时候用 |
|---|---|---|
| greedy | 永远选概率最高的 | 评测、需要复现 |
| temperature | 分布除以 T 再采样,T 越大越随机 | 对话,一般 0.7 到 1.0 |
| top-k | 只在概率最高的 k 个里采样 | 配合 temperature |
| top-p | 累积概率到 p 的最小集合里采样 | 现在更常用,一般 0.9 |
评测一律用 greedy。 因为评测要可复现,采样会引入随机性,同一个模型跑两次分数不一样,没法比较。
对话场景用 temperature 加 top-p。0.5B 的模型建议 temperature 别调太高,它本来就容易跑偏,高温度会让它更加胡说。
七、验收
这是专题最后一篇,验收清单也是整条链路的总检查。
- 困惑度比随机初始化(32,000)低了好几个数量级
- 训练后期的 PPL 明显低于早期 checkpoint
- HellaSwag 准确率超过 25% 的随机基线
- MMLU / C-Eval 跑通并记录(接近随机是正常的)
-
compare_impl.py自比对全 0 - 推理框架和训练端逐层比对,最大差在
1e-2以内 - 用 cache 和不用 cache 的输出一致
- 推理框架里,同一个问题 base / SFT / DPO 三个模型的输出差异肉眼可见
- greedy 解码两次跑出完全相同的结果
倒数第二条是整个专题真正的验收。 拿「帮我写一封请假邮件」去问三个模型:base 应该在续写(可能接出「请假邮件怎么写才礼貌」这种),SFT 应该给一封邮件,DPO 给的那封应该更完整。
三者差异明显,说明数据、模型、训练、后训练这一整条链路都是通的。这比任何一个评测分数都更能说明问题。
八、常见问题
| 现象 | 大概率是什么原因 |
|---|---|
| 推理框架输出通顺但毫无逻辑 | RoPE 约定不一致,见 5.2 节 |
| 推理框架直接输出乱码 | tokenizer 不是同一个 |
| 生成前几个 token 正常,越往后越乱 | KV cache 的 RoPE 位置索引错了,见 5.5 节 |
| 逐层比对自比对就不是 0 | hook 抓的是引用不是拷贝,检查有没有 .detach() |
| 某一层差异突然到 1 以上 | 不是数值误差,是实现不一致,按 5.3 节的表查 |
| 评测准确率正好等于随机基线 | 可能所有选项算出来的分一样,检查 pos 的错位 |
| HellaSwag 分数低于随机 | 长度归一化的方向搞反了 |
| 分数和别人报的对不上 | shot 数不同,或者归一化方式不同,见 2.3 和 2.4 |
| 同一模型两次评测分数不同 | 用了采样,评测要 greedy |
专题到这里就完整了
从 01 篇的 train.bin,到 02 篇的 model.py,到 05 篇训出来的权重,再到这一篇被自制推理框架加载起来说话。整条链路走完,你手上多的不只是一个模型文件,是一套「模型是怎么来的」的完整认知。
至于这个 0.5B 模型本身好不好用——它不好用。但这从来不是这个专题的目标。